import os
import pandas as pd
import ast
def join_csvs_in_folder(folder_path):
# listamos ficheros en carpeta
csv_files = [f for f in os.listdir(folder_path) if f.endswith('.csv')]
data_frames = []
# leo los csv y los pongo en una lista
for file in csv_files:
# low_memory=False elimina los warnings que avisan de no haber indicado el tipo de dato
df = pd.read_csv(os.path.join(folder_path, file), low_memory=False)
# nombre de fichero como columna extra
df["file_name"] = file
data_frames.append(df)
# concatenamos los dataframes
df = pd.concat(data_frames, ignore_index = True)
return df
La lectura de ficheros tan grandes en los que no hemos indicado el tipo de dato para cada columna previamente hace que pandas procese la información de manera muy lenta. Para optimizarlo, y eliminar el banner, bastaría con indicar los tipos de cada columna.
selected_csvs = join_csvs_in_folder("selection/")
#observamos los primeros 5 tweets del conjunto de datos
selected_csvs.head(5)
| Unnamed: 0 | userid | username | acctdesc | location | following | followers | totaltweets | usercreatedts | tweetid | ... | original_tweet_id | original_tweet_userid | original_tweet_username | in_reply_to_status_id | in_reply_to_user_id | in_reply_to_screen_name | is_quote_status | quoted_status_id | quoted_status_userid | quoted_status_username | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1141800 | 1466752038960656385 | Curtin2Tiffany | I am just like everyone else. The universe ex... | Colorado, USA | 253 | 40 | 94 | 2021-12-03 12:52:22.000000 | 1497724980259262467 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 1 | 1141801 | 1111276809302216710 | 5ela60 | الأب ..الأخ ..الجار ..الحبيب .. السديك | NaN | 167 | 9 | 656 | 2019-03-28 14:40:12.000000 | 1497724980271984641 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2 | 1141802 | 1364735420236505088 | StatistWomen | 🇹🇷 | Cumhur İttifakı | 1771 | 1766 | 37009 | 2021-02-25 00:35:28.000000 | 1497724980322349058 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 3 | 1141803 | 597779527 | OurTurnToRescue | Issues: Threats to Democracy, Racism, GOP corr... | NaN | 4847 | 4080 | 33666 | 2012-06-02 21:53:59.000000 | 1497724980573966346 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 4 | 1141804 | 1646145848 | DrWAVeSportCd1 | Addicted to News, Music, Cooking, Gardens, Out... | USA | 5002 | 3950 | 466629 | 2013-08-04 21:07:08.000000 | 1497724980653694976 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
5 rows × 30 columns
#listamos los diferentes campos que existen en el conjunto de datos
list(selected_csvs.columns)
['Unnamed: 0', 'userid', 'username', 'acctdesc', 'location', 'following', 'followers', 'totaltweets', 'usercreatedts', 'tweetid', 'tweetcreatedts', 'retweetcount', 'text', 'hashtags', 'language', 'coordinates', 'favorite_count', 'extractedts', 'file_name', 'is_retweet', 'original_tweet_id', 'original_tweet_userid', 'original_tweet_username', 'in_reply_to_status_id', 'in_reply_to_user_id', 'in_reply_to_screen_name', 'is_quote_status', 'quoted_status_id', 'quoted_status_userid', 'quoted_status_username']
selected_csvs.head()
| Unnamed: 0 | userid | username | acctdesc | location | following | followers | totaltweets | usercreatedts | tweetid | ... | original_tweet_id | original_tweet_userid | original_tweet_username | in_reply_to_status_id | in_reply_to_user_id | in_reply_to_screen_name | is_quote_status | quoted_status_id | quoted_status_userid | quoted_status_username | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1141800 | 1466752038960656385 | Curtin2Tiffany | I am just like everyone else. The universe ex... | Colorado, USA | 253 | 40 | 94 | 2021-12-03 12:52:22.000000 | 1497724980259262467 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 1 | 1141801 | 1111276809302216710 | 5ela60 | الأب ..الأخ ..الجار ..الحبيب .. السديك | NaN | 167 | 9 | 656 | 2019-03-28 14:40:12.000000 | 1497724980271984641 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2 | 1141802 | 1364735420236505088 | StatistWomen | 🇹🇷 | Cumhur İttifakı | 1771 | 1766 | 37009 | 2021-02-25 00:35:28.000000 | 1497724980322349058 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 3 | 1141803 | 597779527 | OurTurnToRescue | Issues: Threats to Democracy, Racism, GOP corr... | NaN | 4847 | 4080 | 33666 | 2012-06-02 21:53:59.000000 | 1497724980573966346 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 4 | 1141804 | 1646145848 | DrWAVeSportCd1 | Addicted to News, Music, Cooking, Gardens, Out... | USA | 5002 | 3950 | 466629 | 2013-08-04 21:07:08.000000 | 1497724980653694976 | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
5 rows × 30 columns
print("filas:"+str(len(selected_csvs))+ "; columnas:"+str(len(list(selected_csvs.columns))))
filas:10570131; columnas:30
Tenemos 30 columnas diferentes, y millones de filas con datos por analizar, visualizar, explotar. Como esta cantidad de datos sobrepasa lo que se pedía en la práctica, vamos a intentar reducirla por algún tipo de valor. Analicemos el idioma de los tweets.
# vamos a observar la cantidad de lenguas diferentes que hay en el conjunto de datos
selected_csvs.language.unique()
array(['en', 'tr', 'uk', 'und', 'de', 'fr', 'es', 'pl', 'nl', 'it', 'ro',
'et', 'th', 'ar', 'ja', 'hi', 'ta', 'pt', 'cs', 'ko', 'ur', 'sv',
'el', 'zh', 'te', 'fa', 'cy', 'ru', 'ht', 'in', 'no', 'da', 'fi',
'ca', 'gu', 'tl', 'mr', 'ml', 'lt', 'iw', 'bg', 'pa', 'kn', 'sl',
'ne', 'bn', 'vi', 'lv', 'sd', 'my', 'hu', 'ka', 'ps', 'or', 'eu',
'ckb', 'am', 'hy', 'is', 'sr', 'si', 'km', 'ug', 'dv', 'bo', 'lo'],
dtype=object)
A continuación, visualizamos el top 10 de idiomas en que están los tweets.
import plotly.graph_objects as go
# contamos el total de tweets por cada lengua, y mostramos el top 10
language_counts = selected_csvs["language"].value_counts().head(10)
#hacemos diagrama de barras
fig = go.Figure(data=[go.Bar(x=language_counts.index, y=language_counts.values,
marker=dict(color=['lightsteelblue', 'lightskyblue','skyblue', 'aqua', 'aquamarine','powderblue', 'darkturquoise', 'slateblue', 'dodgerblue', 'navy']))])
# añadimos etiquetas
fig.update_layout(
title={
'text': 'Top 10 de cantidad de tweets por idioma',
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'}, xaxis_title='Language', yaxis_title='Count')
fig.show()
Como puede observarse, el idioma más hablado en los tweets es el inglés, seguido de "und", (undetermined, idioma no determinado). Esto puede deberse a que haya, por ejemplo, tweets que solo contengan emoticonos o imágenes. También resulta llamativo ver cómo hay más tweets en español que en ucraniano o ruso hablando del conflicto.
# top 10 de usuarios que han twiteado
import plotly.graph_objects as go
# contamos el total de tweets por cada lengua, y mostramos el top 10
top_twitteros = selected_csvs["username"].value_counts().head(10)
top_twitteros_id = selected_csvs["userid"].value_counts().head(10)
#hacemos diagrama de barras
fig = go.Figure(data=[go.Bar(x=top_twitteros.index, y=top_twitteros.values,
marker=dict(color=['purple', 'blue', 'green','red','yellow','orange','pink','gray','brown','black']))])
# añadimos etiquetas
fig.update_layout(title={
'text': 'Top 10 de twiteros',
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'}, xaxis_title='Language', yaxis_title='Count')
fig.show()
Traemos la descripción de cada uno de los perfiles utilizando la API de twitter
import tweepy
import pandas as pd
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import json
with open('details.json') as data_file:
data = json.load(data_file)["credentials"]
# Autenticación
auth = tweepy.OAuthHandler(data["api_key"], data["api_key_secret"])
auth.set_access_token(data["access_token"], data["access_token_secret"])
api = tweepy.API(auth)
# Create an empty list to hold the descriptions
descriptions = []
# Loop through the list of usernames and retrieve the description for each one
for username in list(top_twitteros.keys()):
try:
# Get the user's information
user = api.get_user(screen_name=username)
# Append the user's description to the list
print(username+":"+user.description)
except:
print(f"Error while processing {username}")
FuckPutinBot:I'm a bot. Every minute of every day, I tell Putin to go fuck himself in various languages of the world. #IStandWithUkraine 🇺🇦 kanadianbest:https://t.co/ZJK4Q3dy6l – Your Prime Discount Store For Computers|Electronic|Games|Toys|Drones|Everything Tech & More. Easy Returns | Money Back Guarantee bmurphypointman:#advocate #missing #missingperson #missingchild | #twitter #marketing #organization #nonprofit #fundraising #business #news #writerslift | #BMPRT For #Retweet ArvadaRadio:Playing 80's - 90's Themed Rock. Featuring Local Bands. All Rock / All Local / All The Time Commercial Free! Tell All Your Friends! rogue_corq:@corq's snark account. #cyber #linux #agitprop #cats #Україна 🤜 Bulture Brobby 🤛 🇺🇦 Currently in full #Ukraine News mode 🇺🇦 All cats retweeted. poandpo:News and other interesting stories. Since 2007 https://t.co/655UNA7k59 MadrasTribune:Official Twitter of The Madras Tribune. For press releases/any queries mail us: madrastribune@gmail.com or info@madrastribune.com TheAnswerYes:A fun Yes bot. A parody bot. Don't hurt yourself or others. If you are suicidal, please seek help to get better. Obey laws. Be kind. #TasteTheRainbow BerkleyBearNews:Bringing the news that is important, that as quick as a dog can bring it. IdeallyaNews:#WorldNews, compiled from public broadcasters across the world, and anonymised to reduce bias. In combination, it presents a fairly balanced #NewsFeed
Parece ser que las cuentas más activas, o son bots o pertenecen a noticiarios reconocidos.
Tal y como habíamos adelantado, al existir una cantidad enorme de tweets, vamos a intentar reducir el tamaño para poder cubrir el tope de cantidad de filas que podía tener nuestro dataset. Tomamos los que estén en español solamente
spanish_tweets = selected_csvs[(selected_csvs['language']=='es')]
len(spanish_tweets)
384543
Otro procesado importante que debemos hacer en este punto, es convertir el campo nombre de fichero a fecha. El dataset de Kaggle presenta el día en que se recopilaron los tweets en el título: 20230116_UkraineCombinedTweetsDeduped corresponde al 16 de enero de 2023.
spanish_tweets['date_in_file'] = spanish_tweets['file_name'].str.split('_').str[0]
spanish_tweets['date'] = pd.to_datetime(spanish_tweets['date_in_file'], format='%Y%m%d')
C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\1405153101.py:1: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\1405153101.py:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
spanish_tweets['date'].head()
32 2022-02-27 52 2022-02-27 308 2022-02-27 347 2022-02-27 350 2022-02-27 Name: date, dtype: datetime64[ns]
Ahora que tenemos un campo con la fecha del tweet, puede resultar de interés ver la cantidad de tweets que se han ido escribiendo con el paso de los días en el conflicto. La selección de datos que se ha hecho, es del conjunto de tweets con un intervalo de entre 5 y 7 días entre dumps.
spanish_sorted = spanish_tweets.sort_values(by='date')
date_count = spanish_sorted.groupby('date').count()
fig = go.Figure(data=go.Scatter(x=date_count.index, y=date_count['text'], mode='lines+markers'))
fig.update_layout(title='Cantidad de tuits por fecha', xaxis_title='Date', yaxis_title='Count')
# Display the graph
fig.show()
Este resultado no nos sorprende. Con el paso de los meses, el conflicto se va normalizando en las redes, y pierde el interés de los usuarios de habla hispana.
Como puntos a destacar:
De todos los tweets en español, que siguen siendo una cantidad mayor a 10k, vamos a ver cuántos son retweets y cuántos no
counts = spanish_tweets['is_quote_status'].isna().value_counts()
fig = go.Figure(data=[go.Bar(x=counts.index, y=counts.values, marker=dict(color=['lightsteelblue', 'lightskyblue']))])
fig.update_layout(
title={
'text': 'Cantidad de Tweets Originales vs Retweets',
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'}, xaxis_title='es_retweet', yaxis_title='total')
fig.update_layout(xaxis = dict(tickvals=[True,False], ticktext=['tweets', 'retweets']))
fig.show()
Haciendo 'head' de tweets en español (contenido original, sin retweets), se ha observado que hay tweets sin hashtags
spanish_tweets_original = spanish_tweets[(spanish_tweets['is_quote_status'].isna())]
spanish_tweets_original['hashtags'].head(30)
32 [{'text': 'Sukhoi', 'indices': [86, 93]}, {'te...
52 [{'text': 'Kyiv', 'indices': [47, 52]}]
308 [{'text': 'Ukraine', 'indices': [17, 25]}, {'t...
347 []
350 []
379 [{'text': 'war', 'indices': [135, 139]}]
478 []
502 [{'text': 'Ucrania', 'indices': [159, 167]}, {...
514 [{'text': 'UCRANIA', 'indices': [2, 10]}, {'te...
577 []
580 [{'text': 'NoALaGuerra', 'indices': [103, 115]...
583 []
592 []
641 [{'text': 'UcraniaBajoAtaque', 'indices': [80,...
660 []
671 [{'text': 'Anonymous', 'indices': [16, 26]}, {...
696 [{'text': 'Ukraine', 'indices': [34, 42]}]
711 []
743 [{'text': 'Rusia', 'indices': [116, 122]}, {'t...
757 [{'text': 'Ucrania', 'indices': [127, 135]}]
767 [{'text': 'Urgente', 'indices': [15, 23]}, {'t...
808 [{'text': 'Ukraine', 'indices': [230, 238]}, {...
826 [{'text': 'UcraniaRussia', 'indices': [19, 33]...
872 [{'text': 'Ukrania', 'indices': [42, 50]}, {'t...
874 [{'text': 'Anonymous', 'indices': [16, 26]}, {...
892 [{'text': 'Ukrania', 'indices': [93, 101]}, {'...
911 [{'text': 'UcraniaRussia', 'indices': [94, 108...
995 [{'text': 'Ukraine', 'indices': [124, 132]}]
1074 [{'text': 'Ukraine', 'indices': [124, 132]}]
1088 [{'text': 'Rusia', 'indices': [119, 125]}]
Name: hashtags, dtype: object
Observemos los hashtags que más se repiten
#convertimos a lista de diccionarios
spanish_tweets_original['hashtags'] = spanish_tweets_original['hashtags'].apply(lambda x: ast.literal_eval(x))
C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\139796560.py:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
from collections import Counter
# Define a function to count the occurrences of each hashtag
def count_hashtags(hashtags):
hashtags_count = Counter()
for ht in hashtags:
hashtags_count[ht['text'].lower()] += 1
return hashtags_count
# Count the occurrences of each hashtag
hashtags_count = spanish_tweets_original['hashtags'].apply(count_hashtags)
hashtags_count = sum(hashtags_count, Counter())
# Top 5 de hashtags
top_hashtags = hashtags_count.most_common(5)
# Creamos un horizontal bar chart
fig = go.Figure(data=[go.Bar(marker=dict(color=['lightsteelblue', 'lightskyblue','skyblue', 'aqua', 'aquamarine']),x=[ht[1] for ht in top_hashtags], y=[ht[0] for ht in top_hashtags], orientation='h')])
fig.update_layout(title='Top 5 Hashtags (sin retweets)', xaxis_title='Count', yaxis_title='Hashtag')
fig.show()
El hashtag más repetido, dentro de los tweets de contenido original, es 'ucrania'.
Veamos cuáles son las palabras más comunes a través de una nube de palabras, esta vez para todos los tweets en español, sean originales o retweets.
from wordcloud import WordCloud
hashtags=spanish_tweets['hashtags'].apply(lambda x: ast.literal_eval(x))
hashtags = [ht['text'] for ht_list in hashtags for ht in ht_list]
hashtags_print = list(set(hashtags))
wordcloud = WordCloud().generate(' '.join(hashtags_print))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
Estudiar la interrelación entre usuarios o contenido, puede ser otra de las áreas más interesantes a explorar en un conjunto de datos de tweets. Comprendiendo cada usuario como un nodo que se interrelaciona con otros nodos, podríamos construir una red de grafos en que viésemos el peso de un usuario, por ejemplo, a partir de la cantidad de followers que tiene. Para representar la interconexión de los usuarios entre sí, podemos crear un chord diagram que muestre los IDs de usuario interconectados.
En un primer intento, se creó con todo el conjunto de datos, pero al aparecer demasiado denso y poco legible, se ha reducido esta visualización al top 100 de usuarios con mayor número de followers. También se eliminan aquellos casos en que los usuarios no son seguidos por otros del conjunto de datos, a fin de simplificar la gráfica aún más.
import pandas as pd
import holoviews as hv
from holoviews import opts, dim
from bokeh.sampledata.les_mis import data
hv.extension('bokeh')
hv.output(size=200)
# conjunto de datos completo
# cooccurrences = spanish_tweets.groupby(['userid','original_tweet_userid']).size().reset_index(name='value')
# cooccurrences['original_tweet_userid'].apply(int)
# cooccurrences3 = cooccurrences[cooccurrences.original_tweet_userid != 0]
# chord = hv.Chord(cooccurrences3)
# chord.opts(
# opts.Chord(edge_color='value', labels='index', node_color='index', cmap='Category20', edge_cmap='Category20', fontsize={'labels': '12pt'}),
# title='Usernames quoting other usernames')
# ordenamos por cantidad de followers y username, y después tomamos el top 500
top_followers = spanish_tweets.groupby('username')['followers'].mean()
top_usernames = top_followers.nlargest(500).index
top_rows = spanish_tweets[spanish_tweets['username'].isin(top_usernames)]
cooccurrences = top_rows.groupby(['username','original_tweet_username']).size().reset_index(name='value')
cooccurrences2 = cooccurrences[cooccurrences.original_tweet_username != 0]
cooccurrences2.head()
| username | original_tweet_username | value | |
|---|---|---|---|
| 0 | AlbertoRavell | ReporteYa | 1 |
| 1 | AlbertoRavell | UKR_token | 1 |
| 2 | CABLENOTICIAS | yinaramostv | 2 |
| 3 | Canales11y13 | T13Noticias | 1 |
| 4 | Canales3y7 | Noti7Guatemala | 1 |
len(cooccurrences2)
64
chord = hv.Chord(cooccurrences2)
chord.opts(
opts.Chord(edge_color='value', labels='index', node_color='index',cmap='Category20', title='Top 100 de usuarios refiriéndose a otros usuarios'))